Basic Experimental Power Analysis via Simulation

A basic analysis of the impact of sample size, effect size, and outcome standard deviation on statistical power in a simple RCT.
Author

Jeff Milliman

Published

July 22, 2026

Introduction

In this post, I am going to run through doing a power analysis via simulation for a basic experimental design - i.e. with two groups (treatment and control) of equal size - in R. To see how covariate adjustment impacts power, I’ll run the analysis both adjusted and unadjusted. I’ll also vary the sample size, the treatment effect, and the standard deviation of the outcome distribution to see how changing these parameters impacts power. Finally, I’ll plot the exaggeration ratio -i.e. the Type M Error (Gelman and Carlin 2014) - to see how the combination of filtering on statistical significance and low statistical power produces estimates that are 2 to 3 times larger than the true effect.

What is statistical power?

My preferred definition of statistical power is the following: “the probability of getting a statistically significant result, conditional on a set of beliefs about the model” (Blair, Coppock, and Humphreys 2023). This avoids all of the confusion about Type 1 vs Type 2 error and controlling error rates. While there are mathematical formulas to calculate statistical power (see Rainey (2025)), my preferred approach to power analysis is via simulation.

In practical terms, this means that you vary a bunch of model parameters, such as sample size, covariates, the treatment effect, size of the treatment group, etc.., generate thousands of simulations for each scenario, and calculate the number of times that you detect a statistically significant effect for your independent variable of interest. Researchers often set a target of 80% power, which means that if you generated a 1000 simulations of a data generating process with a given effect, you would detect a statistically significant effect in 800 of those scenarios at your chosen alpha level - usually p < .05.

In general, statistical power tends to increase as:

  1. the sample size increases
  2. the treatment effect increases
  3. the standard deviation of the distribution of your outcome variable decreases
  4. relevant covariates are adjusted for in the analysis

Why is low power bad?

One reason researchers give for why low power is bad is that researchers want to be sure that they can actually detect an effect of interest before they run a study. If a study has low power then researchers can’t be sure whether there truly is no effect if the result is not statistically significant. As increasing the sample size to increase statistical power tends to reach a point of diminishing returns - i.e. the point in which increasing the sample size has a negligible impact on statistical power - researchers also want to make sure that they can allocate their resources efficiently to reduce costs.

A second reason is that low powered studies, when filtered only for those that are statistically significant, tend to produce exaggerated effect sizes - i.e. effect sizes that are larger than the true effect size (Gelman and Carlin 2014). This is bad because the academic literature tends to select for studies based on statistical significance, which in turn leads to overly optimistic expectations about the impact of policy interventions (DellaVigna and Linos 2022; Simpson 2023).

For more on statistical power, J-PAL and the Evidence in Governance and Policy Network (egap) have good resources on statistical power for social scientists. Links to J-PAL and egap resources on statistical power.

Basic DGP for simple experimental design

In my hypothetical research design, I will model a simple randomized experiment conducted on students with equally sized treatment and control groups. The intervention will be designed to raise student test scores from 3 to 10 points on a 100 point test. I’ll also adjust for three covariates: age, high income (binary indicator), and gender (female, binary indicator), vary the sample size from 30 to 100 by 10, and include a final sample of 200 observations.

Below are the parameters of the simulation:

Intercept = 30

Sample Size = 30, 40, 50, 60, 70, 80 ,90, 100, 200

Outcome Distribution Standard Deviation = 5, 10 (higher noise = lower power)

Treatment Effect (tr) = 3, 5, 7, 10 (treatment group scores between 3 to 10 points higher on average)

Covariate distributions

Age: between 13 and 15 years (equal probability of age 13, 14, or 15)

Female: binary indicator (1 = female, 0 = male) with 60% probability female

High Income: binary indicator (1 = high income, 0 = low income) with 30% probability high income

Covariate Values

Age = 1 (a 1 year increase in age increases test scores by 1 point)

Female = 10 (Females score 10 points higher than Males on average)

High Income = 10 (high income students score 10 points higher than low income students on average)

Before running the simulation, I created a little sample DGP to see if the parameters produce reasonable looking test scores.

Code
#load in libraries
library(tidyverse)
library(broom) #extracting pvalues
library(furrr) #running sims in parallel

#sample DGP for power analysis
set.seed(71)
n <- 50

#outcome distribution
x <- rnorm(n = n, mean = 30, sd = 5)

#error term
e <- rnorm(n = n, mean = 0, sd = 1)

#treatment assignment - prob .5 = equal treatment
d <- rbinom(n =n, size = 1, prob = .5)


#age - between 13 and 15
age <- sample(13:15, size = n, replace = TRUE)


#gender - 60% female
female <-rbinom(n =n, size = 1, prob = .6)


#hi income - 30% hi income
hi_income <- rbinom(n =n, size = 1, prob = .3)

#treatment effect
tr <- 10

#set the outcome
y <- x + (age*1) + (female*10) + (hi_income*10) + 
    (d*tr) + e

data <- cbind(y, d, age, female, hi_income) |>
  as.data.frame()

ols <- lm(y ~ 1 + d + age + female + hi_income, data = data)

#histogram of the simulated test scores
hist(data$y)

Code
print(summary(ols))

Call:
lm(formula = y ~ 1 + d + age + female + hi_income, data = data)

Residuals:
    Min      1Q  Median      3Q     Max 
-11.312  -2.849   0.038   3.375   9.916 

Coefficients:
            Estimate Std. Error t value Pr(>|t|)    
(Intercept)   3.4329    12.3673   0.278  0.78261    
d            10.0692     1.3946   7.220 4.81e-09 ***
age           2.7402     0.8737   3.136  0.00301 ** 
female       12.0786     1.3795   8.756 2.81e-11 ***
hi_income    10.9113     1.4833   7.356 3.03e-09 ***
---
Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1

Residual standard error: 4.7 on 45 degrees of freedom
Multiple R-squared:  0.804, Adjusted R-squared:  0.7866 
F-statistic: 46.16 on 4 and 45 DF,  p-value: 2.26e-15

The histogram of the test scores (y) looks ok. The test scores are fairly uniformly spread between 30 and 80, although the distribution is skewed slightly to the left, with a higher peak around test scores from 45 to 55. The test scores are maybe a little bit on the low side, but the distribution looks reasonable. With the exception of the intercept, the coefficients are all in the ball park of the covariate values that were simulated.

Function to run Power Analysis

To run the power analysis, I wrap the DGP in a function and return the coefficient values, along with a binary indicator of statistical significance for the treatment variable.

Code
#wrap DGP  in a function
power_function <- function(tr, n, prob_tr, mean, sd,
                           age_eff, female_eff, income_eff,
                           sig_level){
  
  #set outcome distribution
  x <- rnorm(n = n, mean = mean, sd = sd)
  
  #error term
  e <- rnorm(n = n, mean = 0, sd = 1)
  
  #set treatment assignment
  d <- rbinom(n = n, size = 1, prob = prob_tr)
  
  #age - between 13 and 15
  age <- sample(13:15, size = n, replace = TRUE)
  
  #gender - 60% female
  female <- rbinom(n =n, size = 1, prob = .6)
  
  #hi income - 30% hi income
  hi_income <- rbinom(n =n, size = 1, prob = .3)
  
  
  #set the outcome
  y <- x + (age*age_eff) + (female*female_eff) + (hi_income*income_eff) + 
    (d*tr) + e
  
  #grab the data
  df <- cbind(y, d, age, female, hi_income) |> 
    as.data.frame()
  
  #run model
  #unadjusted
  ols <- lm(y ~ 1 + d, data = df)
  #adjusted
  ols_adj <- lm(y ~ 1 + d + age + female + hi_income, data = df)
  
  #grab coefficents
  #unadjusted
  tr_unadjust <- coef(ols)[2]
  
  #adjusted
  intercept <- coef(ols_adj)[1]
  tr_coef <- coef(ols_adj)[2]
  age_coef <- coef(ols_adj)[3]
  female_coef <- coef(ols_adj)[4]
  income_coef <- coef(ols_adj)[5]
  
  #grab pvalue
  pvalue <- broom::tidy(ols)$p.value[2]
  pvalue_adjust <- broom::tidy(ols_adj)$p.value[2]
  
  #set true effect arguments
  true_treat_eff <- tr
  true_agg_eff <- age_eff
  true_female_eff <- female_eff
  true_income_eff <- income_eff
  
  #make into dataframe
  out <- tibble("intercept" = intercept,
                "control_mean" = mean,
                "sd" = sd,
                "treat_eff_unadj" = tr_unadjust,
                "treat_eff_adj" = tr_coef,
                "true_treat_eff" = true_treat_eff,
                "age_eff" = age_coef,
                "true_age_eff" = true_agg_eff,
                "female_eff" = female_coef,
                "true_female_eff" = true_female_eff,
                "income_eff" = income_coef,
                "true_income_eff" = true_income_eff, 
                "sample_size" = n,
                "treat_pvalue" = pvalue,
                "treat_pvalue_adjust" = pvalue_adjust) |> 
    #create statistical signifigance indicators
    mutate(stat_sig = case_when(treat_pvalue <= sig_level ~ "yes",
                                .default = "no"),
           stat_sig_adjust = case_when(treat_pvalue_adjust <= sig_level ~ "yes",
                                       .default = "no"),
           sig_bin = case_when(stat_sig == "yes" ~ 1,
                               .default = 0),
           sig_bin_adjust = case_when(stat_sig_adjust == "yes" ~ 1,
                                     .default = 0))
  

  return(out)
}

Running the Simulations

Next, I vary my simulation parameters, creating a combination of all possible parameters with a 1000 simulations per scenario. To speed things up, I run the function with parallel processing from the furrr package. The simulation takes about 90 seconds to 2 minutes to run on my laptop.

Code
# Create the grid of all combinations of sim conditions
sim_args <- expand.grid(
  tr = c(3,5,7,10),
  prob_tr = .5,
  age_eff = 1,
  female_eff = 10,
  income_eff = 10,
  #sample size - i.e. n
  n = c(30,40,50,60,70,80,90,100,200),
  mean = 30,
  #vary the standard deviation
  sd = c(5,10),
  sig_level = .05,
  #set 1000 sims
  nsims = 1:1000) |> 
  #drop nsims column for pmap function
  dplyr::select(-nsims)



#wrap in quiet to capture warnings
quiet_sim <- purrr::quietly(power_function)


##set the plan for parallel processing - i.e. with 6 cores
plan(multisession, workers = 8)

#start sim time
tictoc::tic()

#iterate through the sim arguments with the power sim function
sims <- furrr::future_pmap(sim_args, quiet_sim,
                                 #set the seed 
                                 .options = furrr_options(seed = 07192026),
                                 #set the progress bar
                                 .progress = TRUE)



#end time
time_out <- tictoc::toc()
199.64 sec elapsed
Code
#calculate total sim time
sim_time <- time_out$callback_msg

#turn off the parallel process
plan(sequential)



##Extract our coefficients from the estimates into one data frame
sims_coefficients <- sims |> 
  map("result") |> 
  purrr::list_rbind(names_to = "sim_id") 



#grab our summary results - treatment effects and power
summary_results <- sims_coefficients |> 
  #group by true effect size, sample, outcome sd
  dplyr::group_by(true_treat_eff, sample_size, sd) |> 
  dplyr::summarise(
    obs_treat_eff = mean(treat_eff_unadj, na.rm = TRUE),
    obs_treat_eff_adj = mean(treat_eff_adj, na.rm = TRUE),
    power = mean(sig_bin, na.rm = TRUE),
    power_adj = mean(sig_bin_adjust, na.rm = TRUE)) |> 
  ungroup()


#calculate the m error/exaggeration ratio
m_bias <-  sims_coefficients |> 
  filter(stat_sig == "yes") |> 
  #group by model, sample size, and sim distribution
  dplyr::group_by(true_treat_eff, sample_size, sd) |> 
  dplyr::summarise(
    obs_treat_eff = mean(treat_eff_unadj, na.rm = TRUE),
    true_treat_eff = mean(true_treat_eff,na.rm = TRUE),
    obs_treat_eff_adj = mean(treat_eff_adj, na.rm = TRUE),
    #get 95% confidence intervals around the effects
    eff_lo = quantile(treat_eff_unadj, .0275),
    eff_hi = quantile(treat_eff_unadj, .975),
    eff_lo_adj = quantile(treat_eff_adj, .0275),
    eff_hi_adj = quantile(treat_eff_adj, .975)) |> 
  ungroup() |> 
  #calculate m error (exaggeration ratio)
  mutate(mbias = obs_treat_eff/true_treat_eff,
         #calculate CI around mbias
         mbias_lo = eff_lo/true_treat_eff,
         mbias_hi = eff_hi/true_treat_eff,
         mbias_adj = obs_treat_eff_adj/true_treat_eff,
         #calculate CI around mbias adjusted
         mbias_adj_lo = eff_lo_adj/true_treat_eff,
         mbias_adj_hi = eff_hi_adj/true_treat_eff) 

Plot of Power Curve

Code
colors <- c("Adjusted" = "blue", "Unadjusted" = "red", "80% power threshold" = "black")

#power curve
power_curve <- summary_results |> 
  #drop sample size 200
  filter(sample_size <= 100) |> 
  mutate(power = power*100,
         power_adj = power_adj*100,
         true_treat_eff = paste0("effect size ",true_treat_eff),
         true_treat_eff = factor(true_treat_eff, 
                                 levels = c("effect size 3", 
                                            "effect size 5",
                                            "effect size 7",
                                            "effect size 10")),
         sd = paste0("outcome sd ", sd),
         sd = factor(sd, levels = c("outcome sd 5", "outcome sd 10"))) |> 
  ggplot(aes(x = sample_size)) + 
  geom_line(aes(y = power, color = "Unadjusted")) +
  geom_line(aes(y = power_adj, color = "Adjusted")) +
  geom_hline(aes(yintercept = 80, color = "80% power threshold"),lwd=1,
               linetype = 2)  +
    labs(title = "Statistical power by effect size and outcome standard deviation", 
         y = "Power (percent statistically significant)",
         x = "Sample Size",
         color = "Legend") +
    scale_color_manual(values = colors,
                     breaks = c("Adjusted", "Unadjusted", "80% power threshold")) +
  scale_y_continuous(breaks = seq(from = 0, to = 100, by = 20), limits = c(0,110))+
  scale_x_continuous(breaks = seq(from = 30, to = 100, by = 10), limits = c(15,110)) + theme_bw() +
    theme(plot.title = element_text(size = 14),
    legend.title = element_text(size = 14), 
    legend.text = element_text(size = 14),
    axis.title.x = element_text(size = 14),
    axis.title.y = element_text(size = 14),
    axis.text.x = element_text(size = 12),
    axis.text.y = element_text(size = 12),
    strip.text.x = element_text(size = 14),
   plot.caption = element_text(size = 12)) +
    facet_wrap(~ true_treat_eff + sd, ncol = 2)

power_curve

Above, I plot the relationship between sample size and statistical power for both the adjusted and unadjusted models for each combination of effect size and outcome standard deviation. As expected, as the sample size increases statistical power increases across all scenarios. Adjusting for covariates also increases statistical power, although the impact of covariate adjustment on statistical power is less noticeable in scenarios where the outcome variable has a higher standard deviation (10 sd vs 5 sd).

Finally, increasing the treatment effect also increases statistical power. At a simulated treatment effect of 3, 80% power is only barely reached with covariate adjustment, an outcome standard deviation of 5, and a sample size of 100. As the treatment effect increases, the adjusted and unadjusted models are more likely to reach 80% power and increasing the outcome standard deviation has less of an impact on power.

Plot of Type M Error (Exaggeration Ratio)

Code
#mbias plot
#pivot the type m error, confidence intervals lo and confidence intervals hi and join togerther
mbias_plot <- m_bias |> 
  filter(sample_size <= 100) |> 
  select(true_treat_eff:sd, mbias,mbias_adj) |> 
  mutate(sample_size = as.factor(sample_size)) |> 
  pivot_longer(cols = c(mbias,mbias_adj),
               names_to = "bias_type",
               values_to = "bias")  |>
  mutate("Model type" = case_when(str_detect(bias_type, "adj") ~ "Adjusted",
                                  .default = "Unadjusted"))  |> 
  select(-bias_type)


#pivot confidence intervals lo
mbias_plot2 <- m_bias |> 
  filter(sample_size <= 100) |> 
  select(true_treat_eff:sd, mbias_lo, mbias_adj_lo) |> 
  mutate(sample_size = as.factor(sample_size)) |> 
  pivot_longer(cols = c(mbias_lo, mbias_adj_lo),
               names_to = "bias_type",
               values_to = "mbias_lo") |>
  mutate("Model type" = case_when(str_detect(bias_type, "adj") ~ "Adjusted",
                                  .default = "Unadjusted"))  |> 
  select(-bias_type)


#pivot confidence intervals hi
mbias_plot3 <- m_bias |> 
  filter(sample_size <= 100) |> 
  select(true_treat_eff:sd, mbias_hi, mbias_adj_hi) |> 
  mutate(sample_size = as.factor(sample_size)) |> 
  pivot_longer(cols = c(mbias_hi, mbias_adj_hi),
               names_to = "bias_type",
               values_to = "mbias_hi") |>
  mutate("Model type" = case_when(str_detect(bias_type, "adj") ~ "Adjusted",
                                  .default = "Unadjusted"))  |> 
  select(-bias_type)



#creat join list
join_list <- list(mbias_plot, mbias_plot2, mbias_plot3)

#join into one df
mbias_plot_df <- reduce(join_list, left_join, by = c("true_treat_eff", "sample_size", "sd", "Model type"))

#drop plot dfs
rm(mbias_plot, mbias_plot2, mbias_plot3)


#plot of type m error by effect size and outcome sd
mbias_plot <-  mbias_plot_df |>
  mutate(true_treat_eff = paste0("effect size ",true_treat_eff),
         true_treat_eff = factor(true_treat_eff, 
                                  levels = c("effect size 3", 
                                             "effect size 5",
                                             "effect size 7",
                                             "effect size 10")),
          sd = paste0("outcome sd ", sd),
          sd = factor(sd, levels = c("outcome sd 5", "outcome sd 10"))) |> 
  ggplot(aes(x = sample_size, y = bias, group = `Model type`, colour = `Model type`)) +
  geom_point(size = 2, position = position_dodge(width = .9)) +
  geom_errorbar(aes(ymin = mbias_lo, ymax = mbias_hi), position = position_dodge(width = .9), size = .7) +
  geom_hline(yintercept = 1, linetype =2)  +
  scale_y_continuous(breaks = seq(from = -3, to = 6, by = 1), limits = c(-3,6)) +
    geom_hline(aes(yintercept = 1, color = "True effect = Observed effect"),lwd=1,
               linetype = 2)  +
  scale_color_manual(values = c("Adjusted" = "blue", 
                                "Unadjusted" = "red",
                                "True effect = Observed effect" = "black"),
                     breaks = c("Adjusted", "Unadjusted", "True effect = Observed effect")) +
  # scale_x_continuous(breaks = seq(from = 30, to = 100, by = 10), limits = c(30,100)) +
  labs(title = "Average Type M Error by sample size, effect size, and outcome standard deviation", 
       y = "Type M error",
       x = "Sample Size",
       color = "Legend",
       caption = "Error bars represent 95% confidence intervals around the average type M error.") +
  theme_bw() +
  theme(
    plot.title = element_text(size = 14),
    legend.title = element_text(size = 14), 
    legend.text = element_text(size = 14),
    axis.title.x = element_text(size = 14),
    axis.title.y = element_text(size = 14),
    axis.text.x = element_text(size = 12),
    axis.text.y = element_text(size = 12),
    strip.text.x = element_text(size = 14),
   plot.caption = element_text(size = 12)) +
  facet_wrap(~ true_treat_eff + sd, ncol = 2)


mbias_plot

In the graph above, I plot the average ratio of the observed effect to the true effect (Type M error) conditional on observing a statistically significant result (p < .05). A ratio of 1 (black dotted line) indicates that the observed effect is equal on average to the true treatment effect.

Ratios bigger than 1 indicate that effects detected are exaggerated, ratios less than 1 indicate that the detected effects are smaller than the true effect, while a negative ratio indicates that the average detected effect has the opposite sign (direction) of the true simulated effect. To get a sense of the range of the ratio of observed to true effects, I included 95% confidence intervals around the average ratio.

As can be expected, lower sample sizes, smaller effect sizes, and a wider standard deviation of the outcome variable tend to increase the size and range of the Type M error and exaggerate the observed effect size. In the scenario with the lowest power (effect size of 3, outcome sd 10, and sample size of 30), the average Type M error is around 2.5 for the adjusted and 3.5 for the unadjusted models. The range of the Type M error is also incredibly large, spanning from -3 to 5 for the unadjusted model and -2 to 4.5 with adjustment, indicating that studies with very low power can produce statistically significant results in the opposite direction of the true effect - i.e. Type S or sign error (Gelman and Carlin 2014). Covariate adjustment also tends to push the size and range of the Type M error closer to the observed effect size (ratio goes to 1). As the treatment effect increases and the outcome standard deviation decreases, the Type M error for both the adjusted and unadjusted models tends to converge around 1, with a smaller range in the observed effect size.

References

Blair, Graeme, Alexander Coppock, and Macartan Humphreys. 2023. Research Design in the Social Sciences: Declaration, Diagnosis, and Redesign. Princeton. NJ: Princeton University Press. doi:10.7910/DVN/HYVPO5.
DellaVigna, Stefano, and Elizabeth Linos. 2022. “RCTs to Scale: Comprehensive Evidence From Two Nudge Units.” Econometrica 90(1): 81–116. doi:10.3982/ECTA18709.
Gelman, Andrew, and John Carlin. 2014. “Beyond Power Calculations: Assessing Type S (Sign) and Type M (Magnitude) Errors.” Perspectives on Psychological Science 9(6): 641–51. doi:10.1177/1745691614551642.
Rainey, Carlisle. 2025. “Power Rules: Practical Statistical Power Calculations.” https://ideas.repec.org//p/osf/osfxxx/5am9q.html (July 22, 2026).
Simpson, Adrian. 2023. “A Recipe for Disappointment: Policy, Effect Size, and the Winner’s Curse.” Journal of Research on Educational Effectiveness 16(4): 643–62. doi:10.1080/19345747.2022.2066588.